TypeScriptのGenericな関数型の型推論の挙動が、直打ちとtype alias経由とで異なる
質問takker.icon
code:function-types.ts
interface Reader {
chunk: unknown;
}
type Parser<R extends Reader = Reader> = <R2 extends R>(reader: R2) => 2;
interface TextReader {
chunk: string;
}
interface ByteReader {
chunk: Uint8Array;
}
const byte: Parser<ByteReader> = () => 2;
test3はエラーになってくれるのに、test1とtest2はエラーにならず代入に成功してしまう
code:function-types.ts
const test1: Parser<TextReader> = byte;
type TestParser = Parser<TextReader>;
const test2: TestParser = byte;
// @ts-expect-error
const test3: <R2 extends TextReader>(reader: R2) => 2 = byte;
typeで函数型を定義すると、実際は代入できなくても代入できてしまう?
何がおかしいと思ってるのかがよくわからないnishio.icon
byteの型Parser<ByteReader>は展開すると<R2 extends ByteReader>(reader: R2) => 2になるtakker.icon
この型の値は<R2 extends TextReader>(reader: R2) => 2型の値に代入できない
実際、test3: <R2 extends TextReader>(reader: R2) => 2にbyteを代入しようとするとエラーになる
ところが、test1: Parser<TextReader>には代入できてしまう
Parser<TextReader>を展開すると<R2 extends TextReader>(reader: R2) => 2になるから、test3と同じ型のはずなのに、エラーにならない
単純なスペルミスだったらいいんだけど
とりあえずもうすこし再現コード小さくするか
gemini.iconによる解説wogikaze.icon
byteの型はParser<ByteReader>です。ByteReaderはReaderのサブタイプですが、TextReaderとは関係ありません。
本来なら、Parser<ByteReader>はParser<TextReader>のサブタイプではないため、test1とtest2はエラーになるはずです。
しかし、TypeScriptの型システムは、関数型の引数の型に関しては反変を考慮しません。つまり、Parser<ByteReader>はParser<TextReader>に暗黙的に代入可能とみなされてしまいます。 ここの共変・可変の説明
共変: 型Aが型Bのサブタイプ(Bを継承・実装している)なら、Aを含む型もBを含む型のサブタイプになる。
反変: 型Aが型Bのサブタイプなら、Aを含む型はBを含む型のスーパータイプ(Bを含む型を継承・実装している)になる。
Parser<...>を使ったときもエラーになるようにしたい
type Parser<R extends Reader = Reader> = <R2 extends R>(reader: R2) => 2;の<R2 extends R>の部分がなんのためにあるのかnishio.icon
そこを削ったらこうなるけどこれが期待してた挙動なのでは?
https://gyazo.com/c1ccd4155d94b9790a01ff4ed92dd52b
あー、なるほど、理解した
const f : (a:never)=>number = (a:number)=>a // no error
反変にもすでに言及があるけど「広い範囲の値を取る関数は、狭い範囲の変数に代入しても実行時にエラーになることはない」のでTSの型チェックではそれはエラーにならない ここで<R2 extends R>と書いてることによって、全ての型のサブタイプであるneverもR2になりえるようになってる
あー、そういう挙動になっちゃうかtakker.icon
(reader: never) => 2には任意の(reader: T) => 2が代入できる
とここまで説明してから検証しようとしたができなかったw
なんとtakker.icon
理解してなかった!
目的を話した方がすっきりしそうtakker.icon
その過程で冒頭の問題が生じた
parser combinatorの略説
code:string-only.ts
type ParserInput = string;
interface ParserOk<A> {
ok: true;
parsed: A;
rest: ParserInput;
}
interface ParserErr {
ok: false;
expected: string;
rest: ParserInput;
}
type ParserResult<A> = ParserOk<A> | ParserErr;
type Parser<A> = (input: ParserInput) => ParserResult<A>;
export const text = <S extends string>(value: S): Parser<S> =>
(input) => input.startsWith(value)
? { ok: true, value, rest: input.slice(value.length) }
: { ok: false, expected: value, rest: input };
export const and = <A, B>(parseA: Parser<A>, parseB: Parser<B>): Parser<A, B> => (input) => {
const result = parseA(input);
if (!result.ok) return result;
const result2 = parseB(result.rest);
if (!result2.ok) return result2;
return {
ok: true,
rest: result2.rest,
};
};
export const or = <A, B>(parseA: Parser<A>, parseB: Parser<B>): Parser<A | B> =>
(input) => {
const result = parseA(input);
if (result.ok) return result;
return parseB(input);
};
text, and, orを使って、文字列のparserを作れる
code:eg-only.ts
import { text, and, or } from "./string-only.ts";
const hoge = text("hoge");
const huga = text("huga");
const foo = text("foo");
const bar = text("bar");
/** /(hoge|hoo)(huga|bar)/にマッチする文字列をparseする */
const parse = and(
or(hoge, foo),
or(huga, bar),
);
// 実行
console.log(parse("hogebar"));
TODO: 単体テスト書く
さて、ここで入力データを任意の型Tにすることを考える
文字列のほかに、バイナリデータUint8Arrayなどもparseできるようになる
Iteratorのような、データが完全に取得できる前に逐次parseするようなparserも書けるようになる
ReadableStreamなどはPromiseが絡むのでこれだけでは難しいが、少し工夫すればできるはず
眠いので続きはまた今度takker.icon
必要なもの
任意の型Input
Inputから任意の長さのデータを読み取る関数shift
string-only.tsでString.slice()で行っていた処理を抽象化したもの
読み出したデータがInputと同じであるとは限らないので、Chunkという別の型を用意する
例えば、string[]から呼び出すとき、結果をjoinしてstringとして返すなど
併せて、残りの解析対象データをInputとして返す
色々実装法がある
shiftの型からInput, Chunkを取り出す & Parserの定義時にすべての型を確定させる
2024-09-27 13:13:48 これ動きません。型をミスってますtakker.icon
後ほど直します
code:generic-types-1.ts
export interface Reader {
}
export type Input<R extends Reader> = R"shift" extends (input: infer I, length: number) => infer _, I ? I :never; export type Chunk<R extends Reader> = R"shift" extends (input: infer _, length: number) => infer C, _ ? C :never; interface ParserOk<A, R extends Reader> {
ok: true;
parsed: A;
rest: Input<R>;
}
interface ParserErr<I, R extends Reader> {
ok: false;
expected: string;
rest: Input<R>;
}
export type ParserResult<A, R extends Reader> = ParserOk<A, R> | ParserErr<R>;
code:generic-1.ts
import type { Input, ParseResult, Reader } from "./generic-types-1.ts";
type Parser<A, R extends Reader> = (input: Input<R>, reader: R) => ParserResult<A, R>;
export const text = <S extends string, R extends Reader>(value: S): Parser<S, R> =>
(prev, { shift }) => {
return shifted === value
? { ok: true, value, rest: next }
: { ok: false, expected: value, rest: prev };
export const and = <A, B, R extends Reader>(parseA: Parser<A, R>, parseB: Parser<B, R>): Parser<A, B, R> => (prev, reader) => {
const result = parseA(prev, reader);
if (!result.ok) return result;
const result2 = parseB(prev, reader);
if (!result2.ok) return result2;
return {
ok: true,
rest: result2.rest,
};
};
export const or = <A, B, R extends Reader>(parseA: Parser<A, R>, parseB: Parser<B, R>): Parser<A | B, R> =>
(prev, reader) => {
const result = parseA(prev);
if (result.ok) return result;
return parseB(prev);
};
code:eg-1.ts
import { text, and, or } from "./generic-1.ts";
const textReader = {
input.slice(0, length),
input.slice(length),
];
};
type TextReader = typeof textReader;
const hoge = text<"hoge", TextReader>("hoge");
const huga = text<"huga", TextReader>("huga");
const foo = text<"foo", TextReader>("foo");
const bar = text<"bar", TextReader>("bar");
/** /(hoge|hoo)(huga|bar)/にマッチする文字列をparseする */
const parse = and(
or(hoge, foo),
or(huga, bar),
);
// 実行
console.log(parse("hogebar", textReader));
任意の型を受け取れるようになったが、Parserを作るときにReaderを明示しなければならなくなった
text<"hoge", TextReader>("hoge")のような記法
書きづらい
引数から推論できるはずの"hoge"まで明示しなければならないのがつらい
そこで、R extends Readerを関数型に埋め込み、型推論をparser利用時まで遅延させる
code:generic-2.ts
import type { Input, ParseResult, Reader } from "./generic-types-1.ts";
type Parser<A> = <R extends Reader>(input: Input<R>, reader: R) => ParserResult<A, R>;
export const text = <S extends string>(value: S): Parser<S> =>
(prev, { shift }) => {
return shifted === value
? { ok: true, value, rest: next }
: { ok: false, expected: value, rest: prev };
export const and = <A, B>(parseA: Parser<A>, parseB: Parser<B>): Parser<A, B> => (prev, reader) => {
const result = parseA(prev, reader);
if (!result.ok) return result;
const result2 = parseB(prev, reader);
if (!result2.ok) return result2;
return {
ok: true,
rest: result2.rest,
};
};
export const or = <A, B>(parseA: Parser<A>, parseB: Parser<B>): Parser<A | B> =>
(prev, reader) => {
const result = parseA(prev);
if (result.ok) return result;
return parseB(prev);
};
code:eg-2.ts
import { text, and, or } from "./generic-2.ts";
const textReader = {
input.slice(0, length),
input.slice(length),
];
};
const hoge = text("hoge");
const huga = text("huga");
const foo = text("foo");
const bar = text("bar");
/** /(hoge|hoo)(huga|bar)/にマッチする文字列をparseする */
const parse = and(
or(hoge, foo),
or(huga, bar),
);
// 実行
// ここでRがtypeof textReader確定する
console.log(parse("hogebar", textReader));
eg-2.tsのようなコードならtype Parser<A> = <R extends Reader>(input: Input<R>, reader: R) => ParserResult<A, R>;で十分
しかし、Rにさらに型制約をかけたい状況があった
続きはまた今度takker.icon
Simple Version
code:ts
interface TypeBase {}
interface TypeA {
chunk: "A";
}
interface TypeB {
chunk: "B";
}
type Gen<R extends TypeBase> = <R2 extends R>(x: R2) => x;
type Gen2<R2 extends TypeBase> = (x: R2) => x;
↑=> xは=> 2?
code:ts
const oA: Gen<TypeA> = (x) => x;
const test1: Gen<TypeB> = oA;
// @ts-expect-error
const test3: <R2 extends TypeB>(x: R2) => x = oA;
// @ts-expect-error
const test4: Gen2<TypeB> = oA; // これが期待した挙動だよね
test4がerrorになるのはわかるのですがtakker.icon
(reader: TypeB) => 2の引数に代入できるが<R extends TypeA>(reader: R) => 2の引数にどうやったって代入できない型が存在する
test1がエラーにならないのにtest3がエラーになるのがわからないです
oAでなぜか引数を省略してるけどちゃんと書くとnishio.icon
https://gyazo.com/55eaadf7ae76c990dd06ca781f446fa4
この型はgenericsになってるからR2 extends TypeAであるようなR2の中でR2 extends TypeBであるような型があるならエラーにならないのが正しいと思う
具体的にはneverはそれだよねと思う
はーんそういうことかtakker.icon
しかし、そうするとtest3がエラーになるのはなぜだろうtakker.icon
Gen<TypeB>と<R2 extends TypeB>(reader: R2) => 2は同じ型のはずだけど
これが下の記事に書いてあることかな(まだ読んでない関係なかった)
同じ型ではないのでは?nishio.icon
<R2 extends TypeB>(reader: R2) => 2はGen2<TypeB>と同じ型だと思ってて、なのでtest4を作って期待通りエラーになった
https://gyazo.com/849a98032d820dac210f3f0eedcda7c7
えっ同じじゃない?まじですかtakker.icon
初歩的な誤認だったのか
確認
Gen<TypeB>
type Gen<R extends TypeBase> = <R2 extends R>(x: R2) => 2;だから、RにTypeBを代入して<R2 extends TypeB>(reader: R2) => 2になる
Gen2<TypeB>
type Gen2<R2 extends TypeBase> = (x: R2) => 2;だから、R2にTypeBを代入して(x: TypeB) => 2になる
↑は↓こう書いたほうがもっとシンプルに確認できるのではmrsekut.icon
code:ts
interface TypeB {
chunk: 'B';
}
// 要はこの2つの挙動に差異があることを確認したいように見える
type Gen = <R2>(x: R2) => 2;
type Gen2<R2> = (x: R2) => 2;
const oA: Gen = x => 2;
const test1: Gen = oA;
// test3とtest4は同じなので省略
// @ts-expect-error
const test4: Gen2<TypeB> = oA;
要はこの2つの挙動に差異があることを確認したい(で合っている?)
code:ts
type Gen = <R2>(x: R2) => 2;
type Gen2<R2> = (x: R2) => 2;
この2つの挙動が違うのは理解できます(たぶん)takker.icon
nishio.iconさんはそのように認識しているみたいですtakker.icon
ただtakker.iconはそうではなくて、↓のtest1が通るのにtest3がエラーになる原因を知りたいです
code:ts
type Gen3<R> = <R2 extends R>(x: R2) => 2;
type TypeA = { chunk: "A" };
type TypeB = { chunk: "B" };
const oA: Gen3<TypeA> = (_) => 2;
const test1: Gen3<TypeB> = oA;
// @ts-expect-error
const test3: <R2 extends TypeB>(x: R2) => 2 = oA;
たしかに、だいぶ謎だ。展開するかどうかで挙動が変わっているように見えるmrsekut.icon
2点確認ポイントがありそう、順番に潰したい
健全な型システムの場合、あるべき(?)挙動は〇〇である
↑test1 or test3
ここは関数のvariantの話だmrsekut.icon
Gen3<TypeA>とGen3<TypeB>のサブタイプの関係を確認する必要がある
ただ、今回の場合、TypeAとTypeBにはサブタイプの関係はない
なのでtest3があるべき挙動、のはずだけど……takker.icon
ですよねmrsekut.icon
本来はtest3があっているはずなのに、test1がそうならないのはなぜか
code:ts
/**
* AがBのサブタイプかどうかを判定する
* e.g. IsSubType<'hoge', string>; // 'hoge' <: string なので true
*/
type IsSubType<A, B> = A extends B ? true : false;
type Gen<R> = <R2 extends R>(x: R2) => 2;
type TypeA = { chunk: "A" };
type TypeB = { chunk: "B" };
type OA = Gen<TypeA>;
type Test1 = IsSubType<OA, Gen<TypeB>>; // true. なぜ?
type Test3 = IsSubType<OA, <R2 extends TypeB>(x: R2) => 2>; // false. そうだね
typescriptってそういう一貫性のない挙動をすることがよくあるから、それの一例なのかもしれない
なにこの挙動takker.icon
TypeScriptこわい
type aliasにするかどうかで変わる点が本件と一緒
もしかしたらtypescriptのrepoでissueが立っているかもしれませんtakker.icon
ただ検索キーワードがわからない……
僕もそう思ってちょっと探してみましたが、issueが多すぎて(?)見つけられなかったmrsekut.icon
wontfixでcloseされてるかもしれないnishio.icon
IsSubType便利!nishio.icon
そしてだいぶ問題がシンプルになった
mrsekut.iconさんありがとうtakker.icon
TypeAとTypeBに互換性がないのに、それぞれをGenで包むと互換性が出るのはなぜか
R2が共通のサブタイプneverになりうるからじゃない?というのが仮説
それを検証する手段がわからない
それと別にGenをtypeで名付けた場合と、名付けないで直接同じ内容を書いた場合に振る舞いが違うという問題がある
こちらはまったく謎
いや?これTypeBで実体化されたやつではなく、まだジェネリクスの状態なのか?
いや、それは元のでも同じか
Github Copilot Chatに聞いてみたtakker.icon
Github Copilot Chat.icon
test1が通り、test3で型エラーが発生する理由は、TypeScriptの型推論と型の互換性に関する違いによるものです。具体的には、ジェネリック型の制約と型推論の違いが影響しています。
(略上記と同じコード)
詳細な説明
test1について
Gen3<TypeB>は型引数RにTypeBを渡したジェネリック型です。そのため、<R2 extends TypeB>(x: R2) => 2という関数のシグネチャになります。oAは型引数RにTypeAを渡したジェネリック型です。Gen3<TypeA>は<R2 extends TypeA>(x: R2) => 2というシグネチャを持っています。
TypeScriptでは、ジェネリック型の互換性は「構造的部分型」に基づいて評価されます。TypeAはTypeBの部分型であるため、Gen3<TypeA>はGen3<TypeB>と互換性があります。よって、test1は型エラーになりません。 TypeAはTypeBの部分型である
これ違いそうmrsekut.icontakker.icon
そしたらなおさら違うじゃん
2回くらいおかしいと指摘したらtakker.icon
おっしゃる通りです。TypeAとTypeBは互いに代入可能な関係にはありません。以前の説明は誤りでした。正しい説明は以下の通りです。
(略)
test1が通るのはおかしいです。正しくは、test1もtest3も型エラーが発生するべきです。おそらく、TypeScriptのバージョンや設定に起因するバグや特殊な挙動があるかもしれません。
と説明を撤回した
test3について
test3の宣言では、具体的な型シグネチャ<R2 extends TypeB>(x: R2) => 2を使用しています。このシグネチャとGen3<TypeA>のシグネチャを直接比較すると、TypeBはTypeAの部分型ではないため、互換性がないと見なされます。したがって、型エラーが発生します。
まとめ
Gen3<TypeB>はGen3<TypeA>と互換性がありますが、具体的な型シグネチャを直接比較する場合は、互換性がないと判断されます。これがtest1が通り、test3が型エラーになる理由です。
そんなことある??takker.icon
手に終えそうになくなってきたtakker.icon
名前を簡潔にしたver.
この件はbug fixされたようだ
(途中でnishio.iconさんと認識ずれてそうだと気づいた。早めに聞けばよかったか)
UInt8Arrayとかstringとか全く無関係で、単にgenericsの書き方が適切でないだけ
当初はGen2のように実装したのですが、それでは対処しづらいケースが現れたためGenのような形式に切り替えましたtakker.icon
具体的には後日書きます
GenとGen2でどうして挙動が変わるのかについては...
なるほど?nishio.icon
ちかそうなよかんtakker.icon
とりあえず「実装の都合で特殊な処理になってる」という理解をしたnishio.icon
今回は無関係だった
trueでもfalseでも同じ結果になった
なにこれこわいtakker.icon
code:ts
interface A {
a: number;
}
interface B {
a: string;
}
type IsSubType<A, B> = A extends B ? true : false;
type AB = A & B;
type aba = IsSubType<AB, A> // true
type abb = IsSubType<AB, B> // true
{
type Gen<R> = <R2 extends R>(x: R2) => 2;
type Test1 = IsSubType<Gen<A>, Gen<B>> // true
type Test2 = IsSubType<Gen<B>, Gen<A>> // true
type Test3 = IsSubType<Gen<A>, <R2 extends B>(x: R2) => 2> // false
type Test4 = IsSubType<<R2 extends A>(x: R2) => 2, Gen<B>> // false
type Test5 = IsSubType<<R2 extends A>(x: R2) => 2, <R2 extends B>(x: R2) => 2> // false
}
{
type Gen<R> = <R2 extends R>() => R2;
type Test1 = IsSubType<Gen<A>, Gen<B>> // true
type Test2 = IsSubType<Gen<B>, Gen<A>> // true
type Test3 = IsSubType<Gen<A>, <R2 extends B>() => R2> // false
type Test4 = IsSubType<<R2 extends A>() => R2, Gen<B>> // false
type Test5 = IsSubType<<R2 extends A>() => R2, <R2 extends B>() => R2> // false
}
うーんnishio.icon
返り値の型にしても関係ない
Genericsを展開すると挙動が変わる
両方展開してもfalse
なにもわからない...
これだなmiyamonz.icon
In cases where generic arguments haven't been instantiated they are substituted by any before checking compatibility:
code:ts
let identity = function<T>(x: T): T {
// ...
}
let reverse = function<U>(y: U): U {
// ...
}
identity = reverse; // Okay because (x: any)=>any matches (y: any)=>any
この主張の根拠がどこにあるか(公式docかgithub上での記載など)は調べてないです。分からんmiyamonz.icon
でもたぶん合ってるんでしょうmiyamonz.icon
こんな変換が挟まってしまうのかtakker.icon
たぶんこういうことですmiyamonz.icon
Genを経由すると、この挙動によってanyを経由するのでエラーが起きない
Genを経由しないと、型推論が素直に動き、高階型でインスタンス化されてない型の代入可能性をチェックしてエラーで落ちる
ちなみに、IsSubTypeでtrue or falseにするとエラーの原因が追いづらいので、素直に代入するとよいですよ
https://gyazo.com/39ab7e8a4d77c3e06b79b125698de457
これはエラー文のそのままそのとおりで、R2はgenericなtypeでインスタンス化できないからそもそも判定しようがない
あと、ここまで上記は素朴に置換可能な記述で結果が異なることに対する調査ですが、
対して、そもそもじゃあどう書くべきだったか、という視点では、
TSで型レベルの高階関数はむずい
type Gen<T> = <T2 extends T>()=>T2 これは、Tを受けて、T2を受けたら()=>T2を返す型 なので高階型になってる そもそも、typeの記述だと無理なやつ
どうしてもやりたいなら、interfaceを駆使しよう
そうです!miyamonz.icon
同じハックですがこれも分かりやすいです
ありがとうございます!takker.icon
すでにlike押してた記事だったw
という話がありますmiyamonz.icon
ここいらがTypescriptの限界みたいですね……調査ありがとうございますtakker.icon
interfaceとthisで頑張るのも一応TSの限界の内なので頑張ってみるのは面白いかもです、頭こんがらがりそうですがmiyamonz.icon
長いので誰か要約して(他力本願)Mijinko_SD.icon